--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit a59b20c2ced911ca31d2b3866e18e41ca5c7ddd4
Parents : 97a8b07
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T17:35:47-05:00
feat(licenses_collector): implement license collection for Python and Node dependencies, including metadata extraction and frontend license handling
Changes
6 files changed, 747 insertions(+), 0 deletions(-)
Diff
diff --git a/meshchatx/src/backend/data/licenses_frontend.json b/meshchatx/src/backend/data/licenses_frontend.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/meshchatx/src/backend/data/licenses_frontend.json
@@ -0,0 +1 @@
+[]
diff --git a/meshchatx/src/backend/licenses_collector.py b/meshchatx/src/backend/licenses_collector.py
new file mode 100644
index 00000000..b2acab13
--- /dev/null
+++ b/meshchatx/src/backend/licenses_collector.py
@@ -0,0 +1,223 @@
+"""Collect third-party license metadata for Python (backend) and Node (frontend)."""
+
+from __future__ import annotations
+
+import importlib.metadata
+import json
+import shutil
+import subprocess
+import tomllib
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
+
+_ROOT_DIST_CANDIDATES = ("reticulum-meshchatx", "reticulum_meshchatx")
+
+
+def _repo_root() -> Path:
+ import meshchatx
+
+ return Path(meshchatx.__file__).resolve().parent.parent
+
+
+def _license_from_metadata(meta: importlib.metadata.Metadata) -> str:
+ le = meta.get("License-Expression")
+ if le:
+ return str(le).strip()
+ lic = meta.get("License")
+ if lic and str(lic).strip() and str(lic).strip().upper() != "UNKNOWN":
+ return str(lic).strip()
+ classifiers = meta.get_all("Classifier") or []
+ for line in classifiers:
+ if line.startswith("License ::"):
+ return line.split("::", 1)[-1].strip()
+ return "—"
+
+
+def _author_from_metadata(meta: importlib.metadata.Metadata) -> str:
+ a = (meta.get("Author") or "").strip()
+ if a:
+ return a
+ ae = (meta.get("Author-email") or "").strip()
+ if ae:
+ return ae
+ m = (meta.get("Maintainer") or "").strip()
+ if m:
+ return m
+ return "—"
+
+
+def _dist_for_requirement_name(name: str) -> importlib.metadata.Distribution | None:
+ key = canonicalize_name(name)
+ try:
+ return importlib.metadata.distribution(key)
+ except importlib.metadata.PackageNotFoundError:
+ pass
+ try:
+ return importlib.metadata.distribution(name)
+ except importlib.metadata.PackageNotFoundError:
+ return None
+
+
+def _collect_python_transitive(root_names: tuple[str, ...]) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ seen: set[str] = set()
+
+ def visit(name: str) -> None:
+ cname = canonicalize_name(name)
+ if cname in seen:
+ return
+ dist = _dist_for_requirement_name(name)
+ if dist is None:
+ return
+ seen.add(cname)
+ meta = dist.metadata
+ pkg_name = meta.get("Name") or name
+ rows.append(
+ {
+ "name": str(pkg_name),
+ "version": dist.version,
+ "author": _author_from_metadata(meta),
+ "license": _license_from_metadata(meta),
+ },
+ )
+ for req_str in dist.requires or []:
+ if not (req_str or "").strip():
+ continue
+ try:
+ req = Requirement(req_str)
+ except Exception:
+ continue
+ if req.extras and not req.marker:
+ pass
+ if req.marker is not None and not req.marker.evaluate():
+ continue
+ visit(req.name)
+
+ for root in root_names:
+ visit(root)
+
+ rows.sort(key=lambda r: r["name"].lower())
+ return rows
+
+
+def _python_roots_from_pyproject(repo_root: Path) -> tuple[str, ...]:
+ pyproject = repo_root / "pyproject.toml"
+ if not pyproject.is_file():
+ return _ROOT_DIST_CANDIDATES[:1]
+ try:
+ with pyproject.open("rb") as f:
+ data = tomllib.load(f)
+ except OSError:
+ return _ROOT_DIST_CANDIDATES[:1]
+ deps = (data.get("project") or {}).get("dependencies") or []
+ names: list[str] = []
+ for line in deps:
+ try:
+ req = Requirement(line)
+ except Exception:
+ continue
+ names.append(req.name)
+ if not names:
+ return _ROOT_DIST_CANDIDATES[:1]
+ return tuple(sorted(set(names), key=lambda n: n.lower()))
+
+
+def collect_backend_licenses() -> list[dict[str, Any]]:
+ for root in _ROOT_DIST_CANDIDATES:
+ if _dist_for_requirement_name(root) is not None:
+ return _collect_python_transitive((root,))
+ repo = _repo_root()
+ roots = _python_roots_from_pyproject(repo)
+ return _collect_python_transitive(roots)
+
+
+def _flatten_pnpm_licenses_json(data: dict[str, Any]) -> list[dict[str, Any]]:
+ out: list[dict[str, Any]] = []
+ for _license_key, packages in data.items():
+ if not isinstance(packages, list):
+ continue
+ for pkg in packages:
+ if not isinstance(pkg, dict):
+ continue
+ name = pkg.get("name") or "?"
+ versions = pkg.get("versions") or []
+ version = versions[0] if versions else "?"
+ author = pkg.get("author") or "—"
+ if not isinstance(author, str):
+ author = str(author)
+ lic = pkg.get("license") or _license_key or "—"
+ out.append(
+ {
+ "name": name,
+ "version": str(version),
+ "author": author,
+ "license": str(lic),
+ },
+ )
+ out.sort(key=lambda r: r["name"].lower())
+ return out
+
+
+def _load_embedded_frontend_licenses() -> list[dict[str, Any]] | None:
+ data_dir = Path(__file__).resolve().parent / "data"
+ path = data_dir / "licenses_frontend.json"
+ if not path.is_file():
+ return None
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ if not isinstance(raw, list):
+ return None
+ return [x for x in raw if isinstance(x, dict)]
+
+
+def collect_frontend_licenses() -> tuple[list[dict[str, Any]], str]:
+ embedded = _load_embedded_frontend_licenses()
+ repo = _repo_root()
+ if not (repo / "package.json").is_file():
+ if embedded is not None:
+ return embedded, "embedded"
+ return [], "none"
+
+ pnpm = shutil.which("pnpm")
+ if pnpm:
+ try:
+ proc = subprocess.run(
+ [pnpm, "licenses", "list", "--json"],
+ cwd=repo,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ )
+ if proc.returncode == 0 and proc.stdout.strip():
+ parsed = json.loads(proc.stdout)
+ if isinstance(parsed, dict):
+ return _flatten_pnpm_licenses_json(parsed), "pnpm"
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
+ pass
+
+ if embedded is not None:
+ return embedded, "embedded"
+
+ return [], "none"
+
+
+def build_licenses_payload() -> dict[str, Any]:
+ backend = collect_backend_licenses()
+ frontend, fe_source = collect_frontend_licenses()
+ return {
+ "backend": backend,
+ "frontend": frontend,
+ "meta": {
+ "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
+ "backend_count": len(backend),
+ "frontend_count": len(frontend),
+ "frontend_source": fe_source,
+ },
+ }
diff --git a/meshchatx/src/frontend/components/licenses/LicensesPage.vue b/meshchatx/src/frontend/components/licenses/LicensesPage.vue
new file mode 100644
index 00000000..5a986de9
--- /dev/null
+++ b/meshchatx/src/frontend/components/licenses/LicensesPage.vue
@@ -0,0 +1,308 @@
+<template>
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-white dark:bg-zinc-950">
+ <div class="flex-1 flex flex-col min-h-0 overflow-y-auto overscroll-y-contain w-full">
+ <div class="shrink-0 border-b border-gray-200 dark:border-zinc-800 px-3 sm:px-4 md:px-6 py-4 md:py-5">
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between lg:gap-6">
+ <div class="space-y-2 min-w-0 flex-1">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("licenses.section_label") }}
+ </div>
+ <div
+ class="text-xl sm:text-2xl md:text-3xl font-black text-gray-900 dark:text-white tracking-tight"
+ >
+ {{ $t("licenses.title") }}
+ </div>
+ <div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-2xl">
+ {{ $t("licenses.description") }}
+ </div>
+ <p v-if="meta?.generated_at" class="text-xs text-gray-500 dark:text-zinc-500 break-words">
+ {{ $t("licenses.generated_at", { time: meta.generated_at }) }}
+ <span v-if="meta.frontend_source" class="ml-2 inline-block sm:inline">
+ ({{ $t("licenses.frontend_source", { source: meta.frontend_source }) }})
+ </span>
+ </p>
+ </div>
+
+ <div class="w-full lg:max-w-md xl:max-w-sm shrink-0">
+ <div class="relative group">
+ <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
+ />
+ </div>
+ <input
+ v-model="searchQuery"
+ type="search"
+ enterkeyhint="search"
+ autocomplete="off"
+ :placeholder="$t('licenses.search_placeholder')"
+ class="w-full min-h-[44px] sm:min-h-0 pl-10 pr-10 py-3 bg-gray-50 dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 text-base sm:text-sm"
+ />
+ <button
+ v-if="searchQuery"
+ class="absolute inset-y-0 right-0 pr-3 flex items-center min-w-[44px] justify-end text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+ type="button"
+ aria-label="Clear search"
+ @click="searchQuery = ''"
+ >
+ <MaterialDesignIcon icon-name="close-circle" class="size-5" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex-1 min-h-0 p-3 sm:p-4 md:p-6 xl:p-8 w-full max-w-6xl xl:max-w-[min(100%,96rem)] mx-auto flex flex-col gap-4"
+ >
+ <div
+ v-if="loadError"
+ class="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 px-4 py-3 text-sm text-red-800 dark:text-red-200"
+ >
+ {{ loadError }}
+ </div>
+
+ <div
+ v-if="loading"
+ class="flex flex-col items-center justify-center py-16 gap-3 text-gray-500 dark:text-zinc-400"
+ >
+ <MaterialDesignIcon icon-name="loading" class="size-10 animate-spin" />
+ <span>{{ $t("common.loading") }}</span>
+ </div>
+
+ <template v-else>
+ <div class="license-grid grid grid-cols-1 gap-4 xl:grid-cols-2 xl:gap-6 xl:items-start">
+ <details
+ class="license-details rounded-lg border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 open:bg-white dark:open:bg-zinc-950 overflow-hidden"
+ open
+ >
+ <summary
+ class="cursor-pointer select-none px-3 sm:px-4 py-3.5 sm:py-3 min-h-[48px] sm:min-h-0 font-semibold text-sm sm:text-base text-gray-900 dark:text-white flex items-center justify-between gap-2 list-none touch-manipulation"
+ >
+ <span class="min-w-0 break-words pr-2"
+ >{{ $t("licenses.backend_section") }} ({{ filteredBackend.length }})</span
+ >
+ <MaterialDesignIcon
+ icon-name="chevron-down"
+ class="license-details-chevron size-5 shrink-0 opacity-60"
+ />
+ </summary>
+ <div
+ class="license-details-body border-t border-gray-100/80 dark:border-zinc-800/50 max-h-[min(65vh,32rem)] xl:max-h-[min(calc(100dvh-14rem),44rem)] overflow-x-auto overflow-y-auto overscroll-contain px-1 sm:px-2 pb-3 sm:pb-4"
+ >
+ <table class="min-w-full text-left border-collapse text-xs sm:text-sm">
+ <thead>
+ <tr
+ class="sticky top-0 z-[1] border-b border-gray-200 dark:border-zinc-800 bg-gray-50/95 dark:bg-zinc-900/95 backdrop-blur-sm text-gray-600 dark:text-zinc-400"
+ >
+ <th class="py-2 px-2 sm:px-3 font-medium">
+ {{ $t("licenses.col_package") }}
+ </th>
+ <th class="py-2 px-2 sm:px-3 font-medium whitespace-nowrap">
+ {{ $t("licenses.col_version") }}
+ </th>
+ <th class="py-2 px-2 sm:px-3 font-medium">
+ {{ $t("licenses.col_author") }}
+ </th>
+ <th class="py-2 px-2 sm:px-3 font-medium">
+ {{ $t("licenses.col_license") }}
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="row in filteredBackend"
+ :key="'b-' + row.name + row.version"
+ class="border-b border-gray-100 dark:border-zinc-800/80 hover:bg-gray-50/80 dark:hover:bg-zinc-900/60"
+ >
+ <td
+ class="py-2 px-2 sm:px-3 font-mono text-[11px] sm:text-xs text-gray-900 dark:text-zinc-100 align-top"
+ >
+ {{ row.name }}
+ </td>
+ <td
+ class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 align-top whitespace-nowrap"
+ >
+ {{ row.version }}
+ </td>
+ <td
+ class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[10rem] sm:max-w-[14rem] truncate align-top"
+ :title="row.author"
+ >
+ {{ row.author }}
+ </td>
+ <td
+ class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[8rem] sm:max-w-xs align-top break-words"
+ >
+ {{ row.license }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <p
+ v-if="filteredBackend.length === 0"
+ class="text-center py-8 text-gray-500 dark:text-zinc-500 text-sm"
+ >
+ {{ $t("common.no_results") }}
+ </p>
+ </div>
+ </details>
+
+ <details
+ class="license-details rounded-lg border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 open:bg-white dark:open:bg-zinc-950 overflow-hidden"
+ open
+ >
+ <summary
+ class="cursor-pointer select-none px-3 sm:px-4 py-3.5 sm:py-3 min-h-[48px] sm:min-h-0 font-semibold text-sm sm:text-base text-gray-900 dark:text-white flex items-center justify-between gap-2 list-none touch-manipulation"
+ >
+ <span class="min-w-0 break-words pr-2"
+ >{{ $t("licenses.frontend_section") }} ({{ filteredFrontend.length }})</span
+ >
+ <MaterialDesignIcon
+ icon-name="chevron-down"
+ class="license-details-chevron size-5 shrink-0 opacity-60"
+ />
+ </summary>
+ <div
+ class="license-details-body border-t border-gray-100/80 dark:border-zinc-800/50 max-h-[min(65vh,32rem)] xl:max-h-[min(calc(100dvh-14rem),44rem)] overflow-x-auto overflow-y-auto overscroll-contain px-1 sm:px-2 pb-3 sm:pb-4"
+ >
+ <table class="min-w-full text-left border-collapse text-xs sm:text-sm">
+ <thead>
+ <tr
+ class="sticky top-0 z-[1] border-b border-gray-200 dark:border-zinc-800 bg-gray-50/95 dark:bg-zinc-900/95 backdrop-blur-sm text-gray-600 dark:text-zinc-400"
+ >
+ <th class="py-2 px-2 sm:px-3 font-medium">
+ {{ $t("licenses.col_package") }}
+ </th>
+ <th class="py-2 px-2 sm:px-3 font-medium whitespace-nowrap">
+ {{ $t("licenses.col_version") }}
+ </th>
+ <th class="py-2 px-2 sm:px-3 font-medium">
+ {{ $t("licenses.col_author") }}
+ </th>
+ <th class="py-2 px-2 sm:px-3 font-medium">
+ {{ $t("licenses.col_license") }}
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="row in filteredFrontend"
+ :key="'f-' + row.name + row.version"
+ class="border-b border-gray-100 dark:border-zinc-800/80 hover:bg-gray-50/80 dark:hover:bg-zinc-900/60"
+ >
+ <td
+ class="py-2 px-2 sm:px-3 font-mono text-[11px] sm:text-xs text-gray-900 dark:text-zinc-100 align-top"
+ >
+ {{ row.name }}
+ </td>
+ <td
+ class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 align-top whitespace-nowrap"
+ >
+ {{ row.version }}
+ </td>
+ <td
+ class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[10rem] sm:max-w-[14rem] truncate align-top"
+ :title="row.author"
+ >
+ {{ row.author }}
+ </td>
+ <td
+ class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[8rem] sm:max-w-xs align-top break-words"
+ >
+ {{ row.license }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <p
+ v-if="filteredFrontend.length === 0"
+ class="text-center py-8 text-gray-500 dark:text-zinc-500 text-sm"
+ >
+ {{ $t("common.no_results") }}
+ </p>
+ </div>
+ </details>
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+
+export default {
+ name: "LicensesPage",
+ components: { MaterialDesignIcon },
+ data() {
+ return {
+ loading: true,
+ loadError: null,
+ searchQuery: "",
+ backend: [],
+ frontend: [],
+ meta: null,
+ };
+ },
+ computed: {
+ q() {
+ return this.searchQuery.trim().toLowerCase();
+ },
+ filteredBackend() {
+ return this.filterRows(this.backend);
+ },
+ filteredFrontend() {
+ return this.filterRows(this.frontend);
+ },
+ },
+ async mounted() {
+ await this.load();
+ },
+ methods: {
+ filterRows(rows) {
+ if (!this.q) {
+ return rows;
+ }
+ return rows.filter((r) => {
+ const blob = `${r.name} ${r.version} ${r.author} ${r.license}`.toLowerCase();
+ return blob.includes(this.q);
+ });
+ },
+ async load() {
+ this.loading = true;
+ this.loadError = null;
+ try {
+ const res = await window.api.get("/api/v1/licenses");
+ this.backend = res.data.backend || [];
+ this.frontend = res.data.frontend || [];
+ this.meta = res.data.meta || null;
+ } catch (e) {
+ this.loadError = e.response?.data?.error || e.message || "Failed to load licenses";
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+};
+</script>
+
+<style scoped>
+.license-details summary::-webkit-details-marker {
+ display: none;
+}
+.license-details summary::marker {
+ display: none;
+}
+.license-details[open] .license-details-chevron {
+ transform: rotate(180deg);
+}
+.license-details-chevron {
+ transition: transform 0.15s ease;
+}
+.license-details-body {
+ -webkit-overflow-scrolling: touch;
+}
+</style>
diff --git a/tests/backend/test_licenses_api.py b/tests/backend/test_licenses_api.py
new file mode 100644
index 00000000..ae8faedc
--- /dev/null
+++ b/tests/backend/test_licenses_api.py
@@ -0,0 +1,77 @@
+import json
+import shutil
+import tempfile
+from unittest.mock import MagicMock, patch
+
+import pytest
+import RNS
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+
+@pytest.fixture
+def temp_dir():
+ dir_path = tempfile.mkdtemp()
+ yield dir_path
+ shutil.rmtree(dir_path)
+
+
+@pytest.fixture
+def mock_rns_minimal():
+ with (
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
+ ):
+ mock_rns_instance = mock_rns.return_value
+ mock_rns_instance.configpath = "/tmp/mock_config"
+ mock_rns_instance.is_connected_to_shared_instance = False
+ mock_rns_instance.transport_enabled.return_value = True
+
+ mock_id = MagicMock(spec=RNS.Identity)
+ mock_id.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id.hexhash = mock_id.hash.hex()
+ mock_id.get_private_key.return_value = b"test_private_key"
+ yield mock_id
+
+
+@pytest.mark.asyncio
+async def test_licenses_endpoint_returns_json(mock_rns_minimal, temp_dir):
+ payload = {
+ "backend": [
+ {"name": "aiohttp", "version": "1", "author": "x", "license": "Apache-2.0"}
+ ],
+ "frontend": [],
+ "meta": {
+ "generated_at": "2020-01-01T00:00:00Z",
+ "backend_count": 1,
+ "frontend_count": 0,
+ "frontend_source": "none",
+ },
+ }
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch(
+ "meshchatx.src.backend.licenses_collector.build_licenses_payload",
+ return_value=payload,
+ ),
+ ):
+ app_instance = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+
+ handler = None
+ for route in app_instance.get_routes():
+ if route.path == "/api/v1/licenses" and route.method == "GET":
+ handler = route.handler
+ break
+
+ assert handler is not None
+ request = MagicMock()
+ response = await handler(request)
+ assert response.status == 200
+ data = json.loads(response.body)
+ assert data == payload
diff --git a/tests/backend/test_licenses_collector.py b/tests/backend/test_licenses_collector.py
new file mode 100644
index 00000000..75110256
--- /dev/null
+++ b/tests/backend/test_licenses_collector.py
@@ -0,0 +1,60 @@
+from unittest.mock import patch
+
+from meshchatx.src.backend.licenses_collector import (
+ _flatten_pnpm_licenses_json,
+ build_licenses_payload,
+)
+
+
+def test_flatten_pnpm_licenses_json_maps_and_sorts():
+ data = {
+ "MIT": [
+ {
+ "name": "zebra-pkg",
+ "versions": ["2.0.0"],
+ "author": "Z",
+ "license": "MIT",
+ },
+ ],
+ "Apache-2.0": [
+ {"name": "alpha-pkg", "versions": ["1.0.0"], "author": "Alice"},
+ {"name": "no-version", "versions": [], "author": "—"},
+ ],
+ }
+ rows = _flatten_pnpm_licenses_json(data)
+ assert [r["name"] for r in rows] == ["alpha-pkg", "no-version", "zebra-pkg"]
+ alpha = next(r for r in rows if r["name"] == "alpha-pkg")
+ assert alpha["version"] == "1.0.0"
+ assert alpha["author"] == "Alice"
+ assert alpha["license"] == "Apache-2.0"
+ nov = next(r for r in rows if r["name"] == "no-version")
+ assert nov["version"] == "?"
+
+
+def test_flatten_pnpm_licenses_json_non_dict_package_skipped():
+ data = {"MIT": ["not-a-dict", {"name": "ok", "versions": ["1"], "author": "x"}]}
+ rows = _flatten_pnpm_licenses_json(data)
+ assert len(rows) == 1
+ assert rows[0]["name"] == "ok"
+
+
+def test_build_licenses_payload_composes_counts_and_meta():
+ be = [{"name": "rns", "version": "1", "author": "a", "license": "MIT"}]
+ fe = [{"name": "vue", "version": "3", "author": "b", "license": "MIT"}]
+ with (
+ patch(
+ "meshchatx.src.backend.licenses_collector.collect_backend_licenses",
+ return_value=be,
+ ),
+ patch(
+ "meshchatx.src.backend.licenses_collector.collect_frontend_licenses",
+ return_value=(fe, "pnpm"),
+ ),
+ ):
+ payload = build_licenses_payload()
+ assert payload["backend"] == be
+ assert payload["frontend"] == fe
+ assert payload["meta"]["backend_count"] == 1
+ assert payload["meta"]["frontend_count"] == 1
+ assert payload["meta"]["frontend_source"] == "pnpm"
+ assert payload["meta"]["generated_at"].endswith("Z")
diff --git a/tests/frontend/LicensesPage.test.js b/tests/frontend/LicensesPage.test.js
new file mode 100644
index 00000000..08e91260
--- /dev/null
+++ b/tests/frontend/LicensesPage.test.js
@@ -0,0 +1,78 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import LicensesPage from "@/components/licenses/LicensesPage.vue";
+
+window.api = {
+ get: vi.fn(),
+};
+
+describe("LicensesPage.vue", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("loads licenses from the API and renders rows", async () => {
+ window.api.get.mockResolvedValue({
+ data: {
+ backend: [{ name: "alpha-be", version: "1.0.0", author: "A", license: "MIT" }],
+ frontend: [{ name: "zebra-fe", version: "2.0.0", author: "Z", license: "Apache-2.0" }],
+ meta: {
+ generated_at: "2020-01-01T00:00:00Z",
+ frontend_source: "pnpm",
+ },
+ },
+ });
+
+ const wrapper = mount(LicensesPage, {
+ global: {
+ mocks: { $t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '<span class="mdi-stub" />',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ await wrapper.vm.$nextTick();
+
+ expect(window.api.get).toHaveBeenCalledWith("/api/v1/licenses");
+ expect(wrapper.text()).toContain("alpha-be");
+ expect(wrapper.text()).toContain("zebra-fe");
+ });
+
+ it("filters both sections with the search query", async () => {
+ window.api.get.mockResolvedValue({
+ data: {
+ backend: [{ name: "keep-be", version: "1", author: "x", license: "MIT" }],
+ frontend: [{ name: "other-fe", version: "2", author: "y", license: "MIT" }],
+ meta: {},
+ },
+ });
+
+ const wrapper = mount(LicensesPage, {
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '<span class="mdi-stub" />',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ await wrapper.vm.$nextTick();
+
+ await wrapper.find('input[type="search"]').setValue("keep-be");
+ expect(wrapper.vm.filteredBackend.length).toBe(1);
+ expect(wrapper.vm.filteredFrontend.length).toBe(0);
+
+ await wrapper.find('input[type="search"]').setValue("other-fe");
+ expect(wrapper.vm.filteredBackend.length).toBe(0);
+ expect(wrapper.vm.filteredFrontend.length).toBe(1);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────